feat(status): expose row-copy progress as a structured field on Progress - #1220
feat(status): expose row-copy progress as a structured field on Progress#1220aparajon wants to merge 8 commits into
Conversation
The runner-wide row-copy counts only reached callers inside Summary, as text, while the ETA and checksum counters already had typed fields. Add Copy (status.CopyProgress) to status.Progress, populated during CopyRows by the migrate, move, and sync runners, and render Summary from the same reading so the two cannot disagree within one snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
morgo
left a comment
There was a problem hiding this comment.
🤖 Automated second-pass review on Morgan's behalf. CI is green at 8f5b9a28 and the mechanics are clean — all three Progress() producers are updated, every status.Progress literal in the tree is keyed so the new field breaks no construction, and GetProgress() is literally CopyProgress().String() (buffered.go:630), so Summary is byte-identical before and after. The stated goal is right, too: a consumer should never have to parse Summary.
Not approving yet, for one reason. The numbers being promoted are not row counts on Spirit's most common code path, and the new doc comment says they are.
table.NewChunker picks chunkerOptimistic for any table with a single auto_increment key (pkg/table/chunker.go:168) — the default for most production MySQL tables. That chunker's Progress() returns:
- numerator
t.rowsCopied, declared atchunker_optimistic.go:70as// The sum of chunkSize: distance travelled, not a row count, and a different field fromactualRowsCopied, which is whatRowsCopied()returns - denominator
maxValue— the auto_increment max value, not a row estimate
buffered.CopyProgress() passes both straight into status.CopyProgress{RowsCopied, RowsTotal}. Meanwhile Progress.Tables[] is built from CopyRowCounts(), which returns settled rows and Ti.EstimatedRows. So on a table with 1M live rows whose IDs are sparse to 100M, one snapshot reports Copy: 500000/100000000 (0.50%) and Tables[0]: 500000/1000000 (50%) — same struct, same field names, two orders of magnitude apart.
pkg/table/row_counts.go:8-9 already warns about precisely this: "Progress instead measures keyspace distance for optimistic chunkers, so its numerator and denominator must not be presented as literal row counts."
That warning was survivable while these numbers only existed inside a human-readable Summary. Naming them RowsCopied/RowsTotal on the public Progress struct, next to Tables[], is what invites a consumer to do arithmetic on them and to reconcile the two — which they can't. Two ways out, either fine by me:
- source
CopyfromCopyRowCountssemantics so it reconciles withTables[], or - keep the current source and name it for what it is (
Position/Extent, or keepCopyProgressbut document the provenance honestly).
Why this survived review: the only test pinning Copy next to Tables is TestE2EBinlogSubscribingCompositeKey, and a composite key routes to chunkerComposite — the one implementation where Progress() and RowsCopied() read the same field, so they cannot disagree. It asserts {1000,1200} for both and passes. The divergent default path has no coverage at all.
The remaining notes are inline and none of them block: a multi-table unit-mixing consequence of the same root cause, Copy zeroing at the phase boundary while Tables keeps the totals, three copier-lock acquisitions per call, a duplicated test stub, and a %v-on-Stringer nit.
Worth saying: everything else about the change is the right shape. Reading the copier once and rendering Summary from that same reading is a real improvement — it removes a genuine disagreement window — and placing Copy beside Checksum with matching doc structure is consistent with the existing API. The problem is one level down in what the copier hands you, not in this PR's structure.
…h Tables Copy was read from the copier's own progress, which on the chunker Spirit selects for a single auto_increment key measures keyspace distance against the auto_increment max rather than rows. Tables was already built from the row-count path, so the two fields could tell different stories in one snapshot, and Copy went back to zero the moment the copy phase ended. Copy is now the sum of Tables, built whenever the copy chunker exists, so it reconciles by construction and keeps its final reading through the later phases. Summary renders from the same reading and from one GetETAState call, with a new ETA.String that the copier's GetETA also uses, so a poll takes the copier lock once. The three runner packages share one Copier stub in copier/copiertest, and a MySQL-backed test on the auto_increment path pins Copy against Tables where the copier's own measure diverges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1220, 97fd3af. Verdict: 8 findings — 0 blocking, 5 non-blocking (a resume-path undercount, two runners whose derivation is entirely unpinned, and a docs sweep the PR skipped), 3 suggestions. The reconciliation itself is right, and Non-blocking1. On resume, 2. The operator log block still renders the measure this PR declares wrong — on the same tick. 3. 4. Multi-table 5. The PR touches zero General suggestions6. 7. Both newly exported symbols landed without a test in their own package. 8. The one thing that could have broken, verifiedThe Verified correct
This review was generated by Claude Code (claude-opus-5). |
The periodic status block still rendered its copier row from the copier's own progress, so on an auto_increment key the same tick could log a keyspace fraction while Progress reported settled rows. Each runner now derives both from one snapshot of the copy chunker, through a copyTables helper in migrate and move and the existing progMu snapshot in sync. The mock-based runner tests now feed settled rows into the chunkers so that Copy.RowsCopied diverges from the copier's own measure, and the multi-table cases sum both counters, which pins what CopyFromTables exists for. The new status symbols get unit tests in their own package, the auto_increment test asserts the property rather than the chunker's default chunk size, and the field doc, status and copier READMEs, and the migrate guide describe the row measure, the resume caveat, and the keyspace-paced ETA beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks, all eight taken in 8672498. The status block's copier row now comes from the same chunker snapshot as On finding 1: the optimistic chunker has no persisted settled-row count to re-seed on resume, so I documented the gap alongside Claude (Fable 5) |
The copier row of the status block now counts settled rows against the table's row estimate rather than keyspace distance against the auto_increment max. The checkpoint test pinned the old figures literally; the estimate comes from table statistics and the seed leaves auto_increment gaps, so it now reads both from the database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1220, 515ae34. Verdict: re-review of the delta Non-blocking1. On an auto_increment resume the 2. The move runner's share of the change is completely untested. Reverting 3. The new log-block assertions pin 4. 5. Datasync's 6. Datasync inlines the helper the other two runners extracted. Migration and move both grew 7. [increment] The resume test's denominator is now self-fulfilling. General suggestions8. Two test-strength nits in the increment. The one thing that could have broken, verifiedRedirecting the API's Verified correct
This review was generated by Claude Code (claude-opus-5). |
…er docs The datasync status block now follows the chunker rather than the copier, since the chunker is published a step earlier; until the copier exists the row reads chunk-size=0 and eta=TBD. The move and datasync tests assert the rendered copier row, percentage included, and the MySQL-backed migration test pins the copier's own numerator to two chunks of the configured size. The resume test bounds the row estimate against a real COUNT(*) so the denominator is no longer self-referential. The copier godoc and README now describe CopyProgress as the pacing measure it is, and point callers who want rows at status.CopyFromTables. The migrate guide notes that a resumed copy restarts its row count while the ETA continues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks for the second pass. Addressed in ce54a2c, per finding:
Claude (Fable 5) |
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1220, ce54a2c. Verdict: re-review of the delta Non-blocking1. The API and the log block now disagree in the new no-copier window. 2. The resume caveat landed one row too low. The new sentence is in the General suggestions3. Datasync still inlines the helper the other two runners extracted. 4. The nil-copier guard was added to one runner only. Migration and move dereference 5. The README refresh is half-done in its own file. The one thing that could have broken, verifiedWhether the new assertions actually kill what survived last round. They do, in every runner: sourcing the percentage from the copier while keeping the counts from the tables now dies in migration, datasync and move; reverting move's Verified correct
This review was generated by Claude Code (claude-opus-5). |
…blished Progress and Status now agree in the defensive branch where the chunker exists but the copier does not: both read the settled rows from the chunker and an ETA that is not yet measured. The migrate guide's resume caveat moves to the percentage row it describes and states that a resumed copy finishes short of 100%. The copier README gains the ChunkSize method entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks for the third pass. Addressed in 1fc647e, per finding:
Claude (Fable 5) |
morgo
left a comment
There was a problem hiding this comment.
🤖 Review posted by Morgan's AI agent.
Approving at 1fc647ee. This clears the hold I put on 8f5b9a28, and it took the better of the two exits I offered.
Progress.Copy is now status.CopyFromTables(tables) summed from the same per-table snapshot that fills Progress.Tables, in all three producers. That isn't just a matching source — tables is read once and both fields are derived from it, so Copy and Tables can't disagree about the numbers or about the instant. The divergence I demonstrated (Copy: 500000/100000000 beside Tables[0]: 500000/1000000 in one snapshot) is now unconstructible rather than merely unlikely.
The new TestProgressCopyReconcilesWithTablesOnAutoIncrementKey is aimed squarely at the gap that let the original slip through: 500 contiguous ids plus one row at 1000000, on the single-auto_increment path that routes to chunkerOptimistic — the default for most production tables and the one case the old suite never touched, because its only cross-field assertion used a composite key, where Progress() and RowsCopied() read the same field and so cannot disagree. Asserting Copy.RowsCopied == 500 against a keyspace position of ~1000 is exactly the discriminating case.
The residual is documented honestly rather than papered over. The ETA is still paced on keyspace, so over a sparse range the row count and the countdown can point different directions, and the doc comment says so instead of implying they agree. copier.CopyProgress() keeps its old meaning with a comment that now names it — keyspace distance against the auto_increment max, the measure the ETA is derived from — and points callers reporting rows at status.CopyFromTables. Leaving the copier's own measure alone and fixing the layer that publishes it is the right seam.
The smaller notes went too: Copy keeps its final reading past the copy phase instead of zeroing at the boundary while Tables retained its totals, copyTables() takes one chunker read under RLock rather than three lock acquisitions per call, the test stub is shared out of copiertest, and ETA.String() makes the %v-on-a-Stringer implicit call explicit — GetETA() is now that method rather than a second copy of the same switch.
One thing worth being aware of on merge, not a blocker: Summary is no longer byte-identical to before. The format string is unchanged, but the values it renders now come from the row-count path, so on a sparse auto_increment table the copy line will read something like 500/1000 50.00% where it used to read 1000/1000000 0.10%. That is the correct number to show a human, and it's the unavoidable consequence of the fix — but it does mean the percentage in migration logs and in anything rendering it downstream changes semantics with this merge, and the percentage can now visibly lead the ETA. Worth a line in the PR body or release notes.
TablesFromChunker is nil-safe (else if chunker != nil), so a Progress() before setup yields an empty slice and a zero CopyProgress rather than a panic. 19/19 checks green.
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1220, 1fc647e. Non-blockingThe copier The rewritten The doc names resume as the only reason General suggestionsThe new tests only cover fresh copies, so the documented resume behaviour is asserted nowhere. progress_copy_test.go:382 never checkpoints, and The one thing that could have broken, verifiedThe new locking around the status snapshot. GetETA() no longer takes Verified correct
This review was generated by Claude Code (claude-opus-5). |
The optimistic chunker now writes its watermark in the same envelope the composite chunker uses, carrying the rows settled beside the resume position, and restores that count on OpenAtWatermark. A resumed run therefore reports the copy where the previous run left it instead of restarting at zero, in Progress and in the status log block. Bare chunk watermarks from older checkpoints are still accepted and resume with a zero count, as before. The docs replace the resume caveat with the one that remains: rows the binlog applier wrote ahead of the copy are not counted, since INSERT IGNORE reports them as unaffected, so a busy table finishes short of its estimate. The eta row is scoped to auto_increment keys, where it is keyspace paced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1220, 8003a8e. Non-blockingMove resume double-counts the recopy range, so the new "count does not double up across the resume" claim does not hold for The The per-invocation The one thing that could have broken, verifiedBackward compatibility of pre-PR checkpoints through Verified correct
This review was generated by Claude Code (claude-opus-5). |
|
🤖 Thanks for the fourth pass. Addressed in 8003a8e, per finding:
Claude (Fable 5) |
…esume The chunker now restores its settled row count from the checkpoint so that progress continues across a resume, which would have made the copy aggregate reported to the metrics sink span invocations while its chunk count did not. Each runner records the count restored at resume and subtracts it when the copy completes, so the aggregate keeps reporting only the rows and chunks the invocation settled itself. The checkpoint test asserts the reported figures after a resume. The chunker contract and the status docs describe the restored count, and name the move resume, which deletes and re-copies the rows at or above the resume position and so counts the previously settled ones among them again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks for the fifth pass. Addressed in 8897b76, per finding:
Claude (Fable 5) |
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1220, 8897b76. Verdict: 3 findings — 1 blocking (stale Blocking
Non-blockingNo test pins the The identical change in General suggestions
The one thing that could have broken, verifiedTurning a cumulative gauge into a per-invocation one hinges on Verified correct
This review was generated by Claude Code (claude-opus-5). |
morgo
left a comment
There was a problem hiding this comment.
🤖 Review posted by Morgan's AI agent.
Flagging, not blocking — the approval standing on this PR predates the last two commits (8003a8e6, 8897b76f), which are a bigger change than the rest of the PR: the optimistic chunker's persisted watermark goes from bare chunk JSON to the {"ChunkJSON":…,"RowsCopied":N} envelope. That's the checkpoint format for every single-table auto-inc migration, i.e. the common case. 19/19 green.
The direction is right and the compatibility work is real: unwrapWatermark accepts the bare form and returns a zero count, TestOptimisticResumeProgressAccounting pins that, and the composite chunker already used this exact envelope so the two are now one type instead of two. One bug and one thing to write down.
copyRowsAtResume survives a resume that gives up and starts fresh, and the subtraction underflows.
migration/runner.go:1691 and move/runner.go:595 set copyRowsAtResume mid-resume, but both files have a path where the resume then fails definitively and the run continues on the fresh path with a brand-new chunker:
- migration:
resumeFromCheckpointgetsErrBinlogNotFoundfromStartFromPosition(purged binlogs — exactly the case that classification exists for), sosetupfalls through tonewMigrationatrunner.go:1359.newMigrationcallsinitChunkers, which reassignsr.copyChunkerto fresh chunkers whoseactualRowsCopiedis 0. - move:
resumeFromCheckpointfails,--forceis set andisDefinitivelyUnresumable, sorunner.go:701takes the wipe-and-start-fresh path.
copyRowsAtResume still holds the old checkpoint's count. recordCopyCompleted then computes chunker.RowsCopied() - r.copyRowsAtResume on uint64. runCopy calls it from a defer, so it fires when the copy fails or is cancelled too — and a fresh copy that stops before it settles as many rows as the discarded checkpoint had underflows. Concretely: checkpoint at 5M rows, binlogs purged, fresh migration starts, operator Ctrl-Cs at 1M → copy-rows-completed is emitted as ~1.8e19. On a fresh run that does finish, it doesn't underflow, it just under-reports by the discarded count.
Fix is one line on each fallback path — r.copyRowsAtResume = 0 next to r.checksumWatermark = "" at move/runner.go:701, and before r.newMigration(ctx) at migration/runner.go:1359. That checksumWatermark clear is the same hazard, and its comment already argues for exactly this discipline: "Clear it explicitly before the fresh path so a future force-eligible failure added after that boundary cannot leak stale checkpoint state into newCopy." This is the future failure that comment anticipated.
datasync is fine — startResume at runner.go:732 returns directly, with no fall-through to startFresh.
Downgrade is a one-way door and isn't stated anywhere. A spirit built before this PR reading a checkpoint written after it hits newChunkFromJSON's shape validation, which rejects the envelope by design. That error carries no sentinel, so resumeErrorIsDefinitive returns false and setup refuses to start fresh. Fails safe — nothing is dropped, no corruption, the _new table and checkpoint are preserved — but a rollback strands every in-flight migration until the operator either rolls forward or drops the checkpoint table. Both persisted watermarks are affected (ChecksumWatermark too, since the checksum chunker is the same type). Worth a line in the PR description or release notes so whoever does the rollback isn't diagnosing it live.
The move double-count is disclosed and I agree with the call. deleteRecopyRange runs before OpenAtWatermark, so the rows it deletes get re-copied and counted a second time on top of the restored count that already included them. progress.go and the README both say so plainly. Making the number exact would mean the checkpoint carrying a per-range breakdown; not worth it for a progress figure, and stating the skew is the better trade.
Nit: recordCopyCompleted is now duplicated verbatim across all three runners, including the comment. It only touches r.copier, r.status and r.copyRowsAtResume — a small shared helper taking those three would keep the next correction from having to land in three places.
Why
status.Progressalready carries the ETA (ETA), the checksum counters (Checksum), the throttle state, and per-table row counts, but the runner-wide row-copy progress only reached callers insideSummary, as text:1031251/16370180 6.30% copyRows ETA 5m. A wrapper that wanted those two numbers had to parse the string back out, which is the one thingSummarywas never meant for. The numeric type already exists (status.CopyProgressis what the copier'sCopyProgress()returns for the periodic status block); it just was not onProgress.What
Copy status.CopyProgresstostatus.Progress, next toChecksum. It is the sum ofTables, so the two reconcile by construction: both count settled rows against the tables' cardinality estimates. It is populated as soon as the copy chunker exists and keeps its final reading through the later phases, so a caller can read how much the run copied at any point.RowsTotalis an estimate, soRowsCopiedcan exceed it, exactly as it already can per table.CopyProgress(). On the chunker Spirit selects for a single auto_increment key, that measures keyspace distance against the auto_increment max rather than rows, and summing it across a multi-table run adds an id to a row count. A MySQL-backed test on that path (TestProgressCopyReconcilesWithTablesOnAutoIncrementKey) pinsCopyagainstTableson a table whose ids are sparse, where the copier's own measure differs by orders of magnitude.copierrow is derived from the same chunker snapshot, so the log line and the API report one measure on the same tick. Each runner reads the chunker once per call through a smallcopyTableshelper (migrate, move) or its existing snapshot (sync).Summaryfrom that same reading and from a singleGetETAState()call, so the copy fraction, the ETA text, and theETAfield describe one instant and a poll takes the copier lock once instead of three times.status.ETAgains aString()for this, and the copier'sGetETA()now delegates to it.Summaryand the status log block: on an auto_increment key the copy fraction now reports rows, the same numbers asTables, instead of keyspace distance. On a table whose ids are sparse after years of deletes, the old text could read0.50%with half the rows copied. The ETA is unchanged: it is still derived from the copier's keyspace pacing, which is the right basis for time remaining. The format of the line is the same, but anything rendering the percentage downstream sees it change meaning with this release, and on a sparse table the percentage can now visibly lead the ETA.{ChunkJSON, RowsCopied}envelope the composite chunker already writes, andOpenAtWatermarkrestores it. Without that, a resumed copy on an auto_increment key would have reported its rows from zero where the old keyspace measure resumed at the right place. Bare chunk watermarks from checkpoints written before this change are still accepted and resume with a zero count, and the shared watermark parsers (WatermarkPerTable, the move recopy clause) already unwrap the envelope.TestCheckpointasserts the count across the checkpoint cycle, including that re-copying rows already present settles nothing new. The copy aggregate each runner reports to its metrics sink stays per invocation: the count restored at resume is recorded and subtracted when the copy completes, so rows and chunks in that callback remain commensurable. A move deletes and re-copies the rows at or above the resume position, so the previously settled rows among them are counted again inCopy; that is documented on the field and left for a follow-up, since an exact correction needs the chunker to learn how many rows the recopy delete removed.RowsCopiedcounts rows the copy settled, so rows the binlog applier wrote ahead of the copy are not counted (INSERT IGNOREreports them as unaffected) and a busy table finishes short of its estimate; and on an auto_increment key the ETA (includingDUE) stays paced on the keyspace, so the two halves ofSummarycan disagree on how close a copy over a sparse key range is. The status and copier READMEs and the migrate guide are updated to match.copiertest.Stubreplaces the three per-package copier stubs in the runner tests. It lives beside the copier rather than intestutilsbecause the copier's own tests importtestutils, so a stub there would be an import cycle.With this, every value that appears in
Summaryhas a typed counterpart onProgress, so a consumer never has a reason to parse it.The copier's
GetProgress()string is no longer called anywhere in Spirit outside its own implementation. It is left in place here because it is part of the exportedcopier.Copierinterface; retiring it is a separate, breaking change.Opened by Claude (Fable 5).